在過去幾天,我們完成了宏偉的探索任務。我們的應用程式已經能夠繪製出任何藍牙裝置的完整「藏寶圖」,UI 上動態生成的服務與特徵面板,就是我們辛勤工作的成果。所有的寶箱(特徵)都已陳列在我們面前,並且我們清楚地知道每一個寶箱上掛著的是「讀取鎖」、「寫入鎖」還是「通知鎖」。
但是,寶箱本身不是寶藏,箱子裡的內容才是。
今天,我們將學習如何打開這些寶箱。我們將從最直接、最常見的操作——讀取 (Read)——開始。我們要為 UI 上那些 Read
按鈕注入真正的靈魂,讓它不再只是一個在控制台自言自語的 console.log
,而是一個能真正從裝置中取回數據的「開鎖匠」。
今天的目標是:點擊「Read」按鈕,透過 characteristic.readValue()
從裝置獲取原始數據,解析它,並將結果光榮地顯示在我們的網頁上。這將是我們第一次看到真實的感測器數據,從物理世界流淌進我們的應用程式!
characteristic.readValue()
這是我們工具箱裡的第一把鑰匙,專門用來打開那些標有 read
屬性的寶箱。
隸屬:BluetoothGATTCharacteristic
物件的方法。也就是我們之前探索到並儲存在 gattProfile
中的那個 characteristic
實例。
非同步:它是一個非同步操作,會回傳一個 Promise
。因為從裝置讀取數據需要時間。
回傳值:當 Promise 成功解析時,它回傳的不是一個簡單的數字或字串,而是一個 DataView
物件。
DataView
這是新手遇到的第一個「坎」,但理解它非常重要。
為什麼不是直接的數字?
藍牙裝置之間為了效率,溝通時傳送的不是我們熟悉的文字 '100',而是最原始、最底層的二進位數據(Bytes,位元組)。一個數字 100 在記憶體中可能只佔用 1 個位元組 01100100。
DataView 是什麼?
你可以把 readValue() 返回的原始數據想像成一塊「未經切割的生肉」(在 JS 中稱為 ArrayBuffer)。DataView 物件則像一把多功能的瑞士刀,它提供了一組工具(方法),讓我們可以從這塊生肉上,按照我們想要的方式,精準地切下需要的部分。
常用的「刀片」 (方法):
dataView.getUint8(byteOffset)
: 將指定位置(byteOffset
)的 1 個位元組,解讀為一個 8 位元無符號整數(範圍 0-255)。這是最常用的方法之一,像電池電量就常用這種格式。
dataView.getInt16(byteOffset, littleEndian)
: 將指定位置的 2 個位元組,解讀為一個 16 位元整數。
還有 getFloat32
、getUint32
等等,用來解讀更複雜的數據。
今天,我們將專注於最簡單的 getUint8(0)
,即讀取第一個位元組的數值。
Read
按鈕我們的戰場,依然是 Day 14 建立的 renderCharacteristic
函式。我們需要找到生成 Read
按鈕的那段 if
邏輯,並將它的 onclick
事件處理器徹底改造。
打開 app.js
,修改 renderCharacteristic
函式:
首先,為了讓 onclick
函式能夠方便地找到它對應的 service
UUID,我們需要稍微修改一下 renderCharacteristic
的參數。
// 修改函式簽名,增加 serviceUuid 參數
function renderCharacteristic(serviceUuid, charInfo, serviceCard) {
// ... 函式其他部分不變 ...
}
// 同時,在昨天呼叫它的地方,也要把 service.uuid 傳進去
// for (const service of services) {
// ...
// for (const characteristic of characteristics) {
// renderCharacteristic(service.uuid, { ... }, serviceCard); // 像這樣
// }
// }
接著,找到 if (charInfo.properties.read)
區塊,用下面的全新版本替換它:
// 在 renderCharacteristic 函式內部
if (charInfo.properties.read) {
const readButton = document.createElement('button');
readButton.textContent = 'Read';
// 將 onclick 事件處理器升級為 async 函式!
readButton.onclick = async () => {
log(`從特徵 ${charInfo.uuid} 讀取資料...`);
try {
// 步驟 1: 從我們的 gattProfile 中獲取特徵的原始實例
const characteristic = gattProfile.services[serviceUuid].characteristics[charInfo.uuid].instance;
if (!characteristic) {
log('錯誤: 找不到特徵實例。');
return;
}
// 步驟 2: 呼叫 readValue() API
const valueDataView = await characteristic.readValue();
log(`> 收到原始 DataView: ${valueDataView.byteLength} bytes`);
// 步驟 3: 解析數據 (此處以最簡單的 uint8 為例)
// getUint8(0) 表示讀取第 0 個 byte (也就是第一個 byte)
const value = valueDataView.getUint8(0);
log(`>> 解析後的數值: ${value}`);
// 步驟 4: 更新 UI 介面
valueSpan.textContent = value;
valueSpan.parentElement.style.backgroundColor = '#d4edda'; // 給個成功提示的背景色
// 步驟 5 (可選但推薦): 更新我們的中央資料庫
gattProfile.services[serviceUuid].characteristics[charInfo.uuid].value = value;
} catch (error) {
log(`[錯誤] 讀取特徵失敗: ${error.message}`);
valueSpan.parentElement.style.backgroundColor = '#f8d7da'; // 給個失敗提示的背景色
}
};
actionContainer.appendChild(readButton);
}
readButton.onclick = async () => { ... }
: 因為 readValue()
是非同步的,我們的事件處理器必須是 async
函式,這樣才能在裡面使用 await
。
gattProfile.services[...].characteristics[...].instance
: 這是閉包強大威力的完美體現!onclick
函式被觸發時,它可以從它被創建時的「記憶」中,拿到 serviceUuid
和 charInfo.uuid
,然後像查字典一樣,從我們的中央資料庫 gattProfile
中,精準地找到我們當初儲存的那個 characteristic
原始物件實例。只有這個實例,才有 .readValue()
方法。
await characteristic.readValue()
: 程式執行到這裡會暫停,等待從藍牙裝置接收到數據。接收成功後,valueDataView
就會是我們得到的 DataView
物件。
UI 更新:我們不僅更新了 valueSpan
的文字,還順便改變了它父層容器的背景顏色,給使用者一個清晰的視覺回饋。
今天,我們為「Read」按鈕注入了真正的靈魂!
讀取操作是一次性的「拉取 (Pull)」——我們主動去要資料。但很多藍牙應用,比如心率監測或溫度計,更需要裝置在數據變化時,能持續地「推送 (Push)」給我們。這就是「訂閱通知 (Subscription)」的用武之地。
明天,我們將為「Subscribe」按鈕注入靈魂,學習如何使用 startNotifications()
來監聽來自裝置的即時數據流,讓我們的應用程式從一個「詢問者」,進化為一個「傾聽者」。
那麼今天的內容就到這邊,感謝你能看到這裡,在這邊祝你早安、午安、晚安,我們明天見。